-
Notifications
You must be signed in to change notification settings - Fork 11
Extend permissions framework to support model-level permissions #1010
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
✅ Deploy Preview for antenna-preview canceled.
|
ami/main/models.py
Outdated
| class Meta: | ||
| ordering = ["-priority", "created_at"] | ||
| permissions = [ | ||
| # Project permissions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be moved to base.models.BaseModel.Meta?
You have to explicitly inherit from the class Meta(BaseModel.Meta)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding create_project here makes sense for the Project model! but for other models, they could either go on that model or the BaseModel with a dynamic method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried moving permissions to the BaseModel, but it turns out Django creates duplicate permissions when they’re inherited one for the BaseModel itself and another for each subclass like Project.
We could instead handle this through a signal that automatically creates model-level permissions for each relevant model at startup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't mind having all the permissions listed under Project.Meta because I think we will move them to Organization.Meta later. Otherwise, I would say put them each under a relevant model like ProcessingService.Meta
… ProcessingService models
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR extends the Antenna permissions framework to support both object-level and model-level permissions, addressing the limitation where only object-level permissions were previously checked using the related project. The enhancement introduces a hybrid system that falls back to Django's model-level permissions when a model is not directly tied to a specific project, and enables all authenticated users to create projects by introducing a new AuthenticatedUsers global role.
Key changes:
- Added model-level permission checking logic to handle models with many-to-many project relationships or no project relation
- Introduced
GlobalRolebase class andAuthenticatedUsersrole to grantcreate_projectpermission to all authenticated users - Added automatic role assignment via post-save signal for new users
Reviewed Changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| ami/users/signals.py | Added post_save signal to auto-assign AuthenticatedUsers role to newly created users |
| ami/users/roles.py | Renamed permissions to object_level_permissions, introduced GlobalRole base class and AuthenticatedUsers role |
| ami/ml/views.py | Added ObjectPermission class and permission check before creating ProcessingService instances |
| ami/ml/tests.py | Updated test user to be superuser to maintain test validity |
| ami/main/tests.py | Added comprehensive test coverage for model-level permissions on Project, ProcessingService, and Taxon models |
| ami/main/models.py | Added permission check methods to Project model and renamed custom permission methods for clarity |
| ami/main/migrations/0080_alter_project_options.py | Added model-level permissions to Project model metadata |
| ami/main/migrations/0079_assign_authenticated_users_group.py | Migration to create AuthenticatedUsers group and assign it to all existing users |
| ami/main/api/views.py | Added ObjectPermission class to TaxonViewSet |
| ami/jobs/views.py | Renamed check_custom_permission to check_custom_object_level_permission |
| ami/jobs/models.py | Renamed custom permission methods and updated logging message |
| ami/base/serializers.py | Added get_instance_for_permission_check method to build unsaved instances for permission validation |
| ami/base/permissions.py | Refactored to use new get_permissions method and model's get_collection_level_permissions |
| ami/base/models.py | Added comprehensive model-level and object-level permission infrastructure with proper fallback logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def assign_model_level_permissions(cls, group): | ||
| from django.contrib.contenttypes.models import ContentType | ||
|
|
||
| ct = ContentType.objects.get_for_model(Project) |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The assign_model_level_permissions method hardcodes the content type to Project, which limits the GlobalRole class to only Project permissions. Consider making this more generic by accepting a model class parameter or deriving the content type from the permission codename.
| logger.debug(f"Project.check_permission action: {action}") | ||
| if action == "create": | ||
| return self.check_model_level_permission(user, action) | ||
| return super().check_object_level_permission(user, action) |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The method calls super().check_object_level_permission(user, action) on line 329, but check_permission is the entry point that should be called. This bypasses the logic in the parent's check_permission method. Call super().check_permission(user, action) instead to ensure proper permission routing.
| return super().check_object_level_permission(user, action) | |
| return super().check_permission(user, action) |
| cls, user: AbstractUser | AnonymousUser, project: "Project | None" = None | ||
| ) -> list[str]: | ||
| # Use model-level permissions for project collection-level actions | ||
| return ["create"] if user.has_perm("main.create_project") else [] |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parameter order differs from the parent class method in ami/base/models.py line 326, which has project before user. This inconsistency could lead to errors when calling the method. Align the parameter order with the base class.
| cls, user: AbstractUser | AnonymousUser, project: "Project | None" = None | |
| ) -> list[str]: | |
| # Use model-level permissions for project collection-level actions | |
| return ["create"] if user.has_perm("main.create_project") else [] | |
| cls, project: "Project | None" = None, user: AbstractUser | AnonymousUser = None | |
| ) -> list[str]: | |
| # Use model-level permissions for project collection-level actions | |
| return ["create"] if user and user.has_perm("main.create_project") else [] |
|
|
||
| def check_model_level_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool: | ||
| model = self._meta.model_name | ||
| app_label = "main" # Assume all model level permissions are in 'main' app |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardcoding app_label to 'main' makes this method inflexible for models in other apps. Use self._meta.app_label instead to automatically determine the correct app label.
| app_label = "main" # Assume all model level permissions are in 'main' app | |
| app_label = self._meta.app_label # Use the actual app label of the model |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with this suggestion, since models in other apps use the BaseModel, the constructed strings will be incorrect. For example:
"create": f"{app_label}.create_{model}"
will be
"create": f"main.create_job" instead of "create": f"jobs.create_job"
| return ["update", "delete", "view"] | ||
|
|
||
| model = self._meta.model_name | ||
| app_label = "main" # self._meta.app_label |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded app_label = 'main' with a commented-out self._meta.app_label suggests this should use the model's actual app label. Use self._meta.app_label to ensure correct permissions are checked for models outside the 'main' app.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree here as well
| """ | ||
| Retrieve collection-level permissions for the given user. | ||
| """ | ||
| app_label = "main" |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to previous comments, hardcoding app_label to 'main' limits reusability. Use cls._meta.app_label to derive the app label from the model class.
| app_label = "main" | |
| app_label = cls._meta.app_label |
| def _assign_user_permission_and_reset_caches( | ||
| self, user, perm_codename: str, app_label: str = "main", model_name: str = "project" | ||
| ): |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This helper method is duplicated in both TestProcessingServiceModelLevelPermissions and TestTaxonModelLevelPermissions classes. Consider extracting it to a shared test base class or utility module to reduce code duplication.
| def _remove_user_permission_and_reset_cache( | ||
| self, user, perm_codename: str, app_label: str = "main", model_name: str = "project" | ||
| ): |
Copilot
AI
Oct 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This helper method is also duplicated across test classes. Extract it along with _assign_user_permission_and_reset_caches to a shared location to improve maintainability.
…t_permissions parameter order
…TestCase to remove duplication
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @mohamedelabbas1996, should I see create_project permission added on the right side here? How do I add or remove the permission from a user?
Also I am not seeing the permission in the /me API response. Where is the frontend checking?
| @action(detail=True, methods=["post"]) | ||
| def register_pipelines(self, request: Request, pk=None) -> Response: | ||
| processing_service = ProcessingService.objects.get(pk=pk) | ||
| processing_service = self.get_object() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch
| serializer = self.get_serializer(data=data) | ||
| serializer.is_valid(raise_exception=True) | ||
| instance_before_creation = serializer.get_instance_for_permission_check() # type: ignore | ||
| self.check_object_permissions(request, instance_before_creation) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does this know to check the create permission?
| return self.check_model_level_permission(user, action) | ||
| return super().check_object_level_permission(user, action) | ||
|
|
||
| def get_permissions(self, user: AbstractUser | AnonymousUser) -> list[str]: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will you add a comment about why get_permission needs to be overwritten here? Is it because Project has no project accessor?
| instance_data = self.get_permissions(instance=instance, instance_data=instance_data) | ||
| return instance_data | ||
|
|
||
| def get_instance_for_permission_check(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is helpful, thanks.
ami/users/roles.py
Outdated
| ) | ||
|
|
||
|
|
||
| class AuthenticatedUsers(GlobalRole): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we rename this to AuthorizedUser? or ApprovedUser
Authenticated implies that they are logged-in. But I think authorized or approved are what we want here.
Also the other roles are singular instead of plural, so I would stick with that pattern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great and comprehensive work @mohamedelabbas1996!
My last big question:
Where do the model level permissions show up in the API? Are they checked by the UI?
|
I can confirm that the Create Project button disappears after I remove a new user from the authorized users group, but I just can't see where the model permissions are being added to the API response with my own eyes. |
Thanks, Michael! The model-level permissions are returned in the user_permissions field of the API response: That’s what the frontend uses to determine which actions to show for each model and object. |
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds comprehensive model-, object-, and collection-level permission APIs, applies object-level enforcement to viewsets, introduces a global AuthorizedUser group and signal to auto-assign it, separates model vs object permissions in roles, adds migrations for group and project permissions, and expands tests and serializer helpers for pre-save permission checks. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant View
participant Serializer
participant Model
participant AuthDB
Client->>View: HTTP request (create/retrieve/update/delete)
View->>Serializer: validate()
alt create/update requires pre-check
Serializer->>Serializer: get_instance_for_permission_check()
Serializer-->>View: unsaved instance
View->>View: check_object_permissions(request, instance)
end
View->>Model: check_permission(user, action)
alt instance scoped to Project
Model->>Model: check_object_level_permission(user, action)
Model->>AuthDB: resolve object-level perms (user/groups)
AuthDB-->>Model: object perms
else model-level permission
Model->>Model: check_model_level_permission(user, action)
Model->>AuthDB: resolve model/group perms
AuthDB-->>Model: model perms
end
alt collection CREATE flow
View->>Model: get_collection_level_permissions(user, project)
Model-->>View: allowed collection actions
end
View-->>Client: 200/403 response
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
♻️ Duplicate comments (3)
ami/base/models.py (1)
185-198: Fix app label lookup for model-level permissions.Hardcoding
app_label = "main"means the new fallback will never honor Django’s model-level permissions for anyBaseModelsubclass outside themainapp—user.has_perm("main.create_foo")can’t match the real permission (foo.create_foo), so create/update/delete remain blocked for those models. Derive the label from the model metadata instead, and mirror the same fix in the other helpers that build permission strings (get_model_level_permissions,get_custom_model_level_permissions,get_collection_level_permissions).- app_label = "main" # Assume all model level permissions are in 'main' app + app_label = self._meta.app_labelami/main/tests.py (2)
3524-3655: Affected by BasePermissionTestCase bug.This test class calls the permission helper methods without specifying
model_name="processingservice", which means permissions are being associated with the wrong ContentType (Project instead of ProcessingService). See the critical issue raised in theBasePermissionTestCasecomment above for the fix.
3657-3799: Affected by BasePermissionTestCase bug.This test class calls the permission helper methods without specifying
model_name="taxon", which means permissions are being associated with the wrong ContentType (Project instead of Taxon). See the critical issue raised in theBasePermissionTestCasecomment above for the fix.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (15)
ami/base/models.py(3 hunks)ami/base/permissions.py(2 hunks)ami/base/serializers.py(1 hunks)ami/jobs/models.py(3 hunks)ami/jobs/views.py(1 hunks)ami/main/api/views.py(2 hunks)ami/main/migrations/0079_assign_authorized_user_group.py(1 hunks)ami/main/migrations/0080_alter_project_options.py(1 hunks)ami/main/migrations/0081_alter_project_options.py(1 hunks)ami/main/models.py(3 hunks)ami/main/tests.py(8 hunks)ami/ml/tests.py(1 hunks)ami/ml/views.py(4 hunks)ami/users/roles.py(6 hunks)ami/users/signals.py(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (13)
ami/main/migrations/0080_alter_project_options.py (1)
ami/main/migrations/0079_assign_authorized_user_group.py (1)
Migration(85-93)
ami/base/serializers.py (1)
ami/main/api/serializers.py (1)
Meta(40-47)
ami/main/migrations/0079_assign_authorized_user_group.py (1)
ami/main/migrations/0080_alter_project_options.py (1)
Migration(6-66)
ami/main/api/views.py (1)
ami/base/permissions.py (1)
ObjectPermission(77-86)
ami/users/signals.py (1)
ami/users/roles.py (4)
AuthorizedUser(170-173)Role(12-52)assign_user(26-34)assign_user(66-73)
ami/jobs/models.py (2)
ami/main/models.py (2)
check_custom_object_level_permission(1870-1873)get_custom_object_level_permissions(1875-1892)ami/base/models.py (2)
check_custom_object_level_permission(247-256)get_custom_object_level_permissions(341-351)
ami/main/models.py (1)
ami/base/models.py (9)
check_permission(200-212)check_model_level_permission(183-198)check_object_level_permission(214-245)get_permissions(258-270)get_object_level_permissions(320-339)get_collection_level_permissions(354-370)check_custom_object_level_permission(247-256)get_project(135-151)get_custom_object_level_permissions(341-351)
ami/ml/views.py (2)
ami/base/permissions.py (1)
ObjectPermission(77-86)ami/base/serializers.py (1)
get_instance_for_permission_check(61-84)
ami/jobs/views.py (3)
ami/main/models.py (1)
check_custom_object_level_permission(1870-1873)ami/base/models.py (1)
check_custom_object_level_permission(247-256)ami/jobs/models.py (1)
check_custom_object_level_permission(952-962)
ami/base/models.py (2)
ami/main/models.py (6)
check_permission(324-337)check_permission(2202-2215)check_custom_object_level_permission(1870-1873)get_permissions(339-340)get_custom_object_level_permissions(1875-1892)get_collection_level_permissions(343-347)ami/base/serializers.py (1)
get_permissions(46-54)
ami/base/permissions.py (3)
ami/main/models.py (2)
get_permissions(339-340)get_collection_level_permissions(343-347)ami/base/models.py (2)
get_permissions(258-270)get_collection_level_permissions(354-370)ami/base/serializers.py (1)
get_permissions(46-54)
ami/users/roles.py (1)
ami/main/models.py (2)
Project(225-485)Permissions(349-418)
ami/main/tests.py (5)
ami/ml/views.py (2)
status(176-182)create(159-173)ami/tests/fixtures/main.py (1)
setup_test_project(114-131)ami/main/models.py (7)
ProjectManager(173-207)Project(225-485)Permissions(349-418)delete(2169-2200)Taxon(3243-3490)create(176-194)name(1023-1024)ami/users/roles.py (5)
ProjectManager(131-167)assign_user(26-34)assign_user(66-73)BasicMember(96-102)Identifier(109-114)ami/ml/models/processing_service.py (1)
create(28-31)
🪛 Ruff (0.14.2)
ami/main/migrations/0080_alter_project_options.py
7-9: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
11-66: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
ami/base/serializers.py
69-69: Avoid specifying long messages outside the exception class
(TRY003)
ami/main/migrations/0079_assign_authorized_user_group.py
7-7: Unused function argument: schema_editor
(ARG001)
19-19: Use logging.exception instead of logging.error
Replace with exception
(TRY400)
52-52: Unused function argument: schema_editor
(ARG001)
57-57: Local variable Permission is assigned to but never used
Remove assignment to unused variable Permission
(F841)
61-61: Local variable project_ct is assigned to but never used
Remove assignment to unused variable project_ct
(F841)
87-89: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
91-93: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
ami/main/migrations/0081_alter_project_options.py
7-9: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
11-68: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
ami/main/api/views.py
1287-1287: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
ami/users/signals.py
78-78: Unused function argument: sender
(ARG001)
78-78: Unused function argument: kwargs
(ARG001)
ami/main/models.py
344-344: Unused class method argument: project
(ARG003)
ami/ml/views.py
146-146: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
ami/base/models.py
326-326: Consider iterable unpacking instead of concatenation
Replace with iterable unpacking
(RUF005)
ami/users/roles.py
15-15: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
58-58: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
173-173: Mutable class attributes should be annotated with typing.ClassVar
(RUF012)
ami/main/tests.py
1289-1289: Possible hardcoded password assigned to argument: "password"
(S106)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: test
🔇 Additional comments (5)
ami/main/tests.py (3)
6-7: LGTM! Appropriate imports for permission testing.The addition of
PermissionandContentTypeimports is necessary for the new model-level permission test cases and follows Django's permission framework correctly.
1262-1267: LGTM! Test correctly reflects new permission model.The test rename and assertion update correctly validate that regular authenticated users can now create projects, aligning with the PR's introduction of the AuthenticatedUsers group with
create_projectpermission.
1276-1390: LGTM! Comprehensive role-based permission testing.This test class provides excellent coverage of the permission framework:
- Validates both backend Guardian permissions and API-level
user_permissionsfields- Tests all major roles (superuser, owner, manager, members, regular users)
- Uses clear helper methods to reduce duplication
- Appropriately verifies collection-level (create) vs object-level (update/delete) permissions
ami/main/api/views.py (2)
178-180: LGTM! Clear documentation of the permission flow.The updated comments effectively explain how the post_save signal assigns the ProjectManager role when both
created=Trueandowneris set. Explicitly saving with the owner ensures the signal has the necessary context for role assignment, which aligns with the model-level permissions framework introduced in this PR.
1287-1287: LGTM! Correctly adds object-level permission checks.Adding
ObjectPermissiontoTaxonViewSetaligns with the PR's objective of extending the permissions framework to support model-level permissions. SinceTaxoncan have many-to-many relationships with projects, applying object-level permission checks ensures proper authorization. This implementation is consistent with other viewsets in the file (e.g.,ProjectViewSet,DeploymentViewSet,SourceImageViewSet).Note: The static analysis hint about
typing.ClassVaris a false positive—permission_classesis a standard Django REST Framework class attribute and doesn't requireClassVarannotation.
| class BasePermissionTestCase(APITestCase): | ||
| """ | ||
| Base class for tests that verify model-level permissions. | ||
| Provides reusable helpers for assigning and removing user permissions. | ||
| """ | ||
|
|
||
| def _assign_user_permission_and_reset_caches( | ||
| self, user, perm_codename: str, app_label: str = "main", model_name: str = "project" | ||
| ): | ||
| ct = ContentType.objects.get(app_label=app_label, model=model_name) | ||
| perm, _ = Permission.objects.get_or_create( | ||
| codename=perm_codename, | ||
| content_type=ct, | ||
| defaults={"name": f"Can {perm_codename.replace('_', ' ')}"}, | ||
| ) | ||
|
|
||
| user.user_permissions.add(perm) | ||
|
|
||
| # Clear cached permissions | ||
| for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]: | ||
| if hasattr(user, attr): | ||
| delattr(user, attr) | ||
| user.refresh_from_db() | ||
|
|
||
| return perm | ||
|
|
||
| def _remove_user_permission_and_reset_cache( | ||
| self, user, perm_codename: str, app_label: str = "main", model_name: str = "project" | ||
| ): | ||
| try: | ||
| ct = ContentType.objects.get(app_label=app_label, model=model_name) | ||
| perm = Permission.objects.get(codename=perm_codename, content_type=ct) | ||
| user.user_permissions.remove(perm) | ||
| except Permission.DoesNotExist: | ||
| return False | ||
|
|
||
| # Clear cached permissions | ||
| for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]: | ||
| if hasattr(user, attr): | ||
| delattr(user, attr) | ||
| user.refresh_from_db() | ||
|
|
||
| return True | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Default model_name parameter causes incorrect ContentType lookup.
The helper methods default model_name="project", but they're used to assign permissions for other models (ProcessingService, Taxon) without overriding this parameter. This causes ContentType.objects.get(model="project") to be used for all models, associating permissions with the wrong content type.
Impact: Tests may pass, but permissions are associated with the Project model instead of ProcessingService or Taxon models, causing permission checks to fail in production.
Apply this diff to fix the issue by deriving the model name from the permission codename:
def _assign_user_permission_and_reset_caches(
- self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
+ self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None
):
+ # Derive model name from permission codename if not explicitly provided
+ # E.g., "create_processingservice" -> "processingservice"
+ if model_name is None:
+ # Remove common action prefixes to get model name
+ for prefix in ["create_", "update_", "delete_", "view_"]:
+ if perm_codename.startswith(prefix):
+ model_name = perm_codename[len(prefix):]
+ break
+ else:
+ # Fallback: use the entire codename as model name
+ model_name = perm_codename
+
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm, _ = Permission.objects.get_or_create(
codename=perm_codename,
content_type=ct,
defaults={"name": f"Can {perm_codename.replace('_', ' ')}"},
)
user.user_permissions.add(perm)
# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()
return perm
def _remove_user_permission_and_reset_cache(
- self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
+ self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None
):
+ # Derive model name from permission codename if not explicitly provided
+ if model_name is None:
+ for prefix in ["create_", "update_", "delete_", "view_"]:
+ if perm_codename.startswith(prefix):
+ model_name = perm_codename[len(prefix):]
+ break
+ else:
+ model_name = perm_codename
+
try:
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm = Permission.objects.get(codename=perm_codename, content_type=ct)
user.user_permissions.remove(perm)
except Permission.DoesNotExist:
return False
# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()
return True📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| class BasePermissionTestCase(APITestCase): | |
| """ | |
| Base class for tests that verify model-level permissions. | |
| Provides reusable helpers for assigning and removing user permissions. | |
| """ | |
| def _assign_user_permission_and_reset_caches( | |
| self, user, perm_codename: str, app_label: str = "main", model_name: str = "project" | |
| ): | |
| ct = ContentType.objects.get(app_label=app_label, model=model_name) | |
| perm, _ = Permission.objects.get_or_create( | |
| codename=perm_codename, | |
| content_type=ct, | |
| defaults={"name": f"Can {perm_codename.replace('_', ' ')}"}, | |
| ) | |
| user.user_permissions.add(perm) | |
| # Clear cached permissions | |
| for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]: | |
| if hasattr(user, attr): | |
| delattr(user, attr) | |
| user.refresh_from_db() | |
| return perm | |
| def _remove_user_permission_and_reset_cache( | |
| self, user, perm_codename: str, app_label: str = "main", model_name: str = "project" | |
| ): | |
| try: | |
| ct = ContentType.objects.get(app_label=app_label, model=model_name) | |
| perm = Permission.objects.get(codename=perm_codename, content_type=ct) | |
| user.user_permissions.remove(perm) | |
| except Permission.DoesNotExist: | |
| return False | |
| # Clear cached permissions | |
| for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]: | |
| if hasattr(user, attr): | |
| delattr(user, attr) | |
| user.refresh_from_db() | |
| return True | |
| class BasePermissionTestCase(APITestCase): | |
| """ | |
| Base class for tests that verify model-level permissions. | |
| Provides reusable helpers for assigning and removing user permissions. | |
| """ | |
| def _assign_user_permission_and_reset_caches( | |
| self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None | |
| ): | |
| # Derive model name from permission codename if not explicitly provided | |
| # E.g., "create_processingservice" -> "processingservice" | |
| if model_name is None: | |
| # Remove common action prefixes to get model name | |
| for prefix in ["create_", "update_", "delete_", "view_"]: | |
| if perm_codename.startswith(prefix): | |
| model_name = perm_codename[len(prefix):] | |
| break | |
| else: | |
| # Fallback: use the entire codename as model name | |
| model_name = perm_codename | |
| ct = ContentType.objects.get(app_label=app_label, model=model_name) | |
| perm, _ = Permission.objects.get_or_create( | |
| codename=perm_codename, | |
| content_type=ct, | |
| defaults={"name": f"Can {perm_codename.replace('_', ' ')}"}, | |
| ) | |
| user.user_permissions.add(perm) | |
| # Clear cached permissions | |
| for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]: | |
| if hasattr(user, attr): | |
| delattr(user, attr) | |
| user.refresh_from_db() | |
| return perm | |
| def _remove_user_permission_and_reset_cache( | |
| self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None | |
| ): | |
| # Derive model name from permission codename if not explicitly provided | |
| if model_name is None: | |
| for prefix in ["create_", "update_", "delete_", "view_"]: | |
| if perm_codename.startswith(prefix): | |
| model_name = perm_codename[len(prefix):] | |
| break | |
| else: | |
| model_name = perm_codename | |
| try: | |
| ct = ContentType.objects.get(app_label=app_label, model=model_name) | |
| perm = Permission.objects.get(codename=perm_codename, content_type=ct) | |
| user.user_permissions.remove(perm) | |
| except Permission.DoesNotExist: | |
| return False | |
| # Clear cached permissions | |
| for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]: | |
| if hasattr(user, attr): | |
| delattr(user, attr) | |
| user.refresh_from_db() | |
| return True |
🤖 Prompt for AI Agents
In ami/main/tests.py around lines 3479-3522, the helper methods default
model_name="project" which causes ContentType lookups to always use the Project
model; change the signature of both helpers to default model_name=None and, if
model_name is None, derive it from the perm_codename by taking the last segment
after splitting on '_' (e.g., perm_codename.split('_')[-1].lower()), then use
that derived model_name for ContentType.objects.get(...); keep the rest of the
logic (creating/getting Permission, adding/removing, clearing caches,
refresh_from_db) unchanged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mohamedelabbas1996 will you make it extra clear in the docstrings that the permissions are managed under the project model? If we had an "Organization" model they may go there instead, but currently all permissions are based on a users association to a project. This PR introduces some actions that happen outside of an existing project (e.g. creating a new project), but there is not a another appropriate place to put those. So we are keeping them on the Project class level, a project instance is not required to make these checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!
|
Notes from our call:
|
|
Note Docstrings generation - SUCCESS |
Docstrings generation was requested by @mihow. * #1010 (comment) The following files were modified: * `ami/base/models.py` * `ami/base/permissions.py` * `ami/base/serializers.py` * `ami/jobs/models.py` * `ami/jobs/views.py` * `ami/main/api/views.py` * `ami/main/migrations/0079_assign_authorized_user_group.py` * `ami/main/models.py` * `ami/main/tests.py` * `ami/ml/tests.py` * `ami/ml/views.py` * `ami/users/roles.py` * `ami/users/signals.py`



Summary
This update extends the Antenna permissions framework to support both object-level and model-level permissions.
Previously, permissions were checked only at the object level, using the related project of the object. However, certain models (e.g., those with many-to-many relationships to projects or without a direct project relation) require model-level permission checks. This enhancement introduces a hybrid system that gracefully falls back to Django’s model-level permissions when a model is not directly tied to a specific project.
List of Changes
Added model-level permission checks
The framework now detects whether an object is not tied to a specific project (or is linked to multiple projects) and applies model-level permissions.
Returned model-level permissions to the frontend
Exposed user permissions through the
user_permissionsfield in API responses:Collection level: exposes create permission.
Object level: exposes update and delete permissions and other custom actions permissions.
Enabled all signed-in users to create projects
Introduced a global role (
AuthorizedUser) that grants model-level create_project permission to every signed-in user.Added a signal to automatically assign the
AuthorizedUserrole to every newly registered user.Related Issues
#1006
Detailed Description
Previously, the Antenna permission framework relied solely on object-level checks using project-based permissions . This approach worked well for most models but did not cover cases where models had many-to-many relationships to projects (such as
ProcessingServiceorPipelinemodels) or did not belong to a specific project, like theProjectmodel itself when creating new instances. Only superusers were allowed to manage these models that had no direct project association.With this update, model-level permissions are introduced , enabling a unified and flexible permission system that supports object-level permissions for project-scoped models and model-level permissions for global or multi-project entities. As part of this change, a new global role
AuthenticatedUsershas been introduced and is automatically assigned to every signed-in user. This role grants the create_project model-level permission, meaning all authenticated users can now create new projects without requiring superuser privileges or membership in any existing project.How to Test the Changes
Screenshots
Deployment Notes
Apply migration 0079_assign_authorized_user_group.py to assign all existing users to the AuthenticatedUsers group, enabling them to create projects.
Checklist
Summary by CodeRabbit
New Features
Improvements
Tests
Chores